iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0
Software Development

Polars熊霸天下系列 第 12

[Day12] - Context:pl.DataFrame.group_by()

  • 分享至 

  • xImage
  •  

今天我們來了解如何使用pl.DataFrame.group_by(),進行聚合運算。

本日大綱如下:

  1. 本日引入模組及準備工作
  2. 基本聚合
  3. 條件式聚合
  4. 針對多列或多expr分組聚合
  5. 常用快速聚合
  6. Window function:pl.Expr.over()
  7. codepanda

0. 本日引入模組及準備工作

import pandas as pd
import polars as pl

data = {
    "name": ["Tom", "Lisa", "John", "Vincent", "Mary", "Caroline"],
    "has_pet": ["Y", "N", "Y", "Y", "Y", "N"],
    "gender": ["M", "F", "M", "M", "F", "F"],
    "lucky_number": [19, 25, 36, 7, 2, 91],
}
df = pl.DataFrame(data)
shape: (6, 4)
┌──────────┬─────────┬────────┬──────────────┐
│ name     ┆ has_pet ┆ gender ┆ lucky_number │
│ ---      ┆ ---     ┆ ---    ┆ ---          │
│ str      ┆ str     ┆ str    ┆ i64          │
╞══════════╪═════════╪════════╪══════════════╡
│ Tom      ┆ Y       ┆ M      ┆ 19           │
│ Lisa     ┆ N       ┆ F      ┆ 25           │
│ John     ┆ Y       ┆ M      ┆ 36           │
│ Vincent  ┆ Y       ┆ M      ┆ 7            │
│ Mary     ┆ Y       ┆ F      ┆ 2            │
│ Caroline ┆ N       ┆ F      ┆ 91           │
└──────────┴─────────┴────────┴──────────────┘

1. 基本聚合

pl.DataFrame.group_by().agg()為聚合的基本型式:

  • group_by()內可以置入一或多個列名/expr做為分組依據。
  • agg()內為一或多個expr,代表聚合的各種操作。

舉例來說,我們可以針對「"has_pet"」列進行分組,並使用pl.len()計算各組所包含的人數及找出各組中「"lucky_number"」列中最大的數值(註1):

df.group_by("has_pet").agg(pl.len(), pl.max("lucky_number"))
shape: (2, 3)
┌─────────┬─────┬──────────────┐
│ has_pet ┆ len ┆ lucky_number │
│ ---     ┆ --- ┆ ---          │
│ str     ┆ u32 ┆ i64          │
╞═════════╪═════╪══════════════╡
│ Y       ┆ 4   ┆ 36           │
│ N       ┆ 2   ┆ 91           │
└─────────┴─────┴──────────────┘

此外,使用expr做為分組依據也是很常見的應用。例如我們想要以「"name"」列中各個名字的長度來做為分組依據,並計算各組所包含的人數以及收集各組所包含的名字,可以這麼寫:

(
    df.group_by(pl.col("name").str.len_bytes().alias("n_chars"))
    .agg(pl.len(), pl.col("name"))
    .sort("n_chars")
)
shape: (4, 3)
┌─────────┬─────┬──────────────────────────┐
│ n_chars ┆ len ┆ name                     │
│ ---     ┆ --- ┆ ---                      │
│ u32     ┆ u32 ┆ list[str]                │
╞═════════╪═════╪══════════════════════════╡
│ 3       ┆ 1   ┆ ["Tom"]                  │
│ 4       ┆ 3   ┆ ["Lisa", "John", "Mary"] │
│ 7       ┆ 1   ┆ ["Vincent"]              │
│ 8       ┆ 1   ┆ ["Caroline"]             │
└─────────┴─────┴──────────────────────────┘

此處使用了pl.col("name").str.len_bytes().alias("n_chars")這個expr來計算名字長度。其中agg()中的pl.col("name")(也可以直接使用列名「"name"」)可以將該組的結果收集為一個pl.List。請注意,為了維持呈現結果,最後使用了pl.DataFrame.sort()將結果以名字的長度由短至長排序。

2. 條件式聚合

對於分組後的聚合操作,我們可能會想要依據某些條件,將結果呈現於不同列。例如想針對「"gender"」列進行分組,並將結果分為兩列,一列是各組中「"lucky_number"」小於20的人數,而另一列則是各組中「"lucky_number"」大於或等於20的人數(註2):

lt20 = pl.col("lucky_number").lt(20)

(
    df.group_by("gender").agg(
        lt20.sum().alias("lt20"), lt20.not_().sum().alias("~lt20")
    )
)
shape: (2, 3)
┌────────┬──────┬───────┐
│ gender ┆ lt20 ┆ ~lt20 │
│ ---    ┆ ---  ┆ ---   │
│ str    ┆ u32  ┆ u32   │
╞════════╪══════╪═══════╡
│ M      ┆ 2    ┆ 1     │
│ F      ┆ 1    ┆ 2     │
└────────┴──────┴───────┘

另一種使用方式是使用pl.Expr.filter()針對各分組結果再進行篩選。例如針對「"gender"」列進行分組,並想要收集各分組內「"lucky_number"」小於20的名字,可以這麼寫:

(
    df.group_by("gender").agg(
        pl.col("name").filter(pl.col("lucky_number").lt(20))
    )
)
shape: (2, 2)
┌────────┬────────────────────┐
│ gender ┆ name               │
│ ---    ┆ ---                │
│ str    ┆ list[str]          │
╞════════╪════════════════════╡
│ M      ┆ ["Tom", "Vincent"] │
│ F      ┆ ["Mary"]           │
└────────┴────────────────────┘

3. 針對多列或多expr分組聚合

我們也可以針對多列或是多個expr進行分組聚合。例如此處我們針對「"has_pet"」列及「"gender"」列進行分組,並收集各組名字為一Pl.List

(df.group_by("has_pet", "gender", maintain_order=True).agg(pl.col("name")))
shape: (3, 3)
┌─────────┬────────┬────────────────────────────┐
│ has_pet ┆ gender ┆ name                       │
│ ---     ┆ ---    ┆ ---                        │
│ str     ┆ str    ┆ list[str]                  │
╞═════════╪════════╪════════════════════════════╡
│ Y       ┆ M      ┆ ["Tom", "John", "Vincent"] │
│ N       ┆ F      ┆ ["Lisa", "Caroline"]       │
│ Y       ┆ F      ┆ ["Mary"]                   │
└─────────┴────────┴────────────────────────────┘

為了方便說明,我們將maintain_order=設為True,並逐一講解如何將原先的六列資料組合為三組:

  • 第一列之「"has_pet"」為「"Y"」而「"gender"」為「"M"」,由於尚未有此分組,所以會建立第一組聚合資料,將「"Tom"」收錄在第一組的「"name"」列中。
  • 第二列之「"has_pet"」為「"N"」而「"gender"」為「"F"」,由於尚未有此分組,所以會建立第二組聚合資料,將「"Lisa"」收錄在第二組的「"name"」列中。
  • 第三列及第四列之「"has_pet"」為「"Y"」而「"gender"」為「"M"」,由於此分組與第一組相同,所以不需建立新組,而直接將「"John"」及「"Vincent"」收錄在第一組的「"name"」列中。
  • 第五列之「"has_pet"」為「"Y"」而「"gender"」為「"F"」,由於尚未有此分組,所以會建立第三組聚合資料,將「"Mary"」收錄在第三組的「"name"」列中。
  • 第六列之「"has_pet"」為「"N"」而「"gender"」為「"F"」,由於此分組與第二組相同,所以不需建立新組,而直接將「"Caroline"」收錄在第二組的「"name"」列中。

4. 常用快速聚合

針對一些常用的聚合,Polars提供了一個便捷的寫法,詳情可以參考API文件。假如我們想要針對「"has_pet"」列進行分組,並計算各組所包含的人數,除了我們已經熟悉的agg()搭配pl.len()外:

df.group_by("has_pet", maintain_order=True).agg(pl.len())

也可以這麼寫:

df.group_by("has_pet", maintain_order=True).len()

兩者會得到一樣的結果:

shape: (2, 2)
┌─────────┬─────┐
│ has_pet ┆ len │
│ ---     ┆ --- │
│ str     ┆ u32 │
╞═════════╪═════╡
│ Y       ┆ 4   │
│ N       ┆ 2   │
└─────────┴─────┘

5. Window function:pl.Expr.over()

pl.Expr.over()中提到,這個expr的功能就像是PostgreSQL中的window function。

簡單地說,pl.Expr.over()是可以針對分組後資料進行個別運算的expr。舉例來說,假如我們想針對「"gender"」列進行分組運算,計算每組內「"lucky_number"」列的rank(即排序各組內的「"lucky_number"」列,最小值為1,次小值為2,以此類推),可以這麼寫(註3):

(
    df.with_columns(
        pl.col("lucky_number")
        .rank("ordinal")
        .over("gender")
        .alias("rank_by_gender")
    )
)
shape: (6, 5)
┌──────────┬─────────┬────────┬──────────────┬────────────────┐
│ name     ┆ has_pet ┆ gender ┆ lucky_number ┆ rank_by_gender │
│ ---      ┆ ---     ┆ ---    ┆ ---          ┆ ---            │
│ str      ┆ str     ┆ str    ┆ i64          ┆ u32            │
╞══════════╪═════════╪════════╪══════════════╪════════════════╡
│ Tom      ┆ Y       ┆ M      ┆ 19           ┆ 2              │
│ Lisa     ┆ N       ┆ F      ┆ 25           ┆ 2              │
│ John     ┆ Y       ┆ M      ┆ 36           ┆ 3              │
│ Vincent  ┆ Y       ┆ M      ┆ 7            ┆ 1              │
│ Mary     ┆ Y       ┆ F      ┆ 2            ┆ 1              │
│ Caroline ┆ N       ┆ F      ┆ 91           ┆ 3              │
└──────────┴─────────┴────────┴──────────────┴────────────────┘

可以看出,「"rank_by_gender"」列總共有兩組「123」,分別代表該行在各自性別的排序。

以第一行為例,根據「"gender"」列來看,「"Tom"」屬於「"M"」群組,其「"lucky_number"」為「19」,介於同組的「"Vincent"」及「"John"」之間,所以其「"rank_by_gender"」為2。

事實上,如果使用pl.DataFrame.group_by().agg()搭配pl.DataFrame.explode(),可以做出類似上面的結果:

(
    df.group_by("gender", maintain_order=True)
    .agg(
        pl.col("lucky_number").rank("ordinal").alias("rank_by_gender"),
        pl.all(),
    )
    .explode(pl.all().exclude("gender"))
    .select([*df.columns, "rank_by_gender"])
)
shape: (6, 5)
┌──────────┬─────────┬────────┬──────────────┬────────────────┐
│ name     ┆ has_pet ┆ gender ┆ lucky_number ┆ rank_by_gender │
│ ---      ┆ ---     ┆ ---    ┆ ---          ┆ ---            │
│ str      ┆ str     ┆ str    ┆ i64          ┆ u32            │
╞══════════╪═════════╪════════╪══════════════╪════════════════╡
│ Tom      ┆ Y       ┆ M      ┆ 19           ┆ 2              │
│ John     ┆ Y       ┆ M      ┆ 36           ┆ 3              │
│ Vincent  ┆ Y       ┆ M      ┆ 7            ┆ 1              │
│ Lisa     ┆ N       ┆ F      ┆ 25           ┆ 2              │
│ Mary     ┆ Y       ┆ F      ┆ 2            ┆ 1              │
│ Caroline ┆ N       ┆ F      ┆ 91           ┆ 3              │
└──────────┴─────────┴────────┴──────────────┴────────────────┘

可以觀察出來,其「"gender"」會是同組一起呈現,而不是像前一個例題那樣交叉呈現。

此外,pl.Expr.over()有一個重要的mapping_strategy=參數,總共有「"group_to_rows"」、「"explode"」及 「"join"」三種選項(預設為「"group_to_rows"」),可以改變呈現結果。以下我們建立一個df2 dataframe幫助說明,其內除了有df的「"name"」及「"gender"」列,還新增了一列「"rank"」列。請注意「"rank"」列內共有六個數字,代表這是全局的排列順序,不是如前面例題的個別分組排序。

df2 = df.with_columns(
    pl.Series("rank", [5, 6, 4, 1, 2, 3], dtype=pl.UInt32)
).select("name", "gender", "rank")
shape: (6, 3)
┌──────────┬────────┬──────┐
│ name     ┆ gender ┆ rank │
│ ---      ┆ ---    ┆ ---  │
│ str      ┆ str    ┆ u32  │
╞══════════╪════════╪══════╡
│ Tom      ┆ M      ┆ 5    │
│ Lisa     ┆ F      ┆ 6    │
│ John     ┆ M      ┆ 4    │
│ Vincent  ┆ M      ┆ 1    │
│ Mary     ┆ F      ┆ 2    │
│ Caroline ┆ F      ┆ 3    │
└──────────┴────────┴──────┘

以下我們使用pl.Expr.over()針對「"gender"」列分組,並依各組內的「"rank"」排序後,觀察三種mapping_strategy=參數的結果有什麼不同。

group_to_rows

「"group_to_rows"」將分組後的結果「置回」原先的dataframe,這也是其參數名的由來,即將「"group"」置入「"rows"」中。

(
    df2.select(
        pl.all()
        .sort_by(pl.col("rank"))
        .over(pl.col("gender"), mapping_strategy="group_to_rows"),
    )
)
shape: (6, 3)
┌──────────┬────────┬──────┐
│ name     ┆ gender ┆ rank │
│ ---      ┆ ---    ┆ ---  │
│ str      ┆ str    ┆ u32  │
╞══════════╪════════╪══════╡
│ Vincent  ┆ M      ┆ 1    │
│ Mary     ┆ F      ┆ 2    │
│ John     ┆ M      ┆ 4    │
│ Tom      ┆ M      ┆ 5    │
│ Caroline ┆ F      ┆ 3    │
│ Lisa     ┆ F      ┆ 6    │
└──────────┴────────┴──────┘

explode

「"explode"」的效果與「"group_to_rows"」很像,但是其結果會是同組一起呈現。教學文件特別指出,如果各組內之行排序不重要時,應優先使用「"explode"」,因為這樣Polars可以減少「"group to rows"」的操作。

(
    df2.select(
        pl.all()
        .sort_by(pl.col("rank"))
        .over(pl.col("gender"), mapping_strategy="explode")
    )
)
shape: (6, 3)
┌──────────┬────────┬──────┐
│ name     ┆ gender ┆ rank │
│ ---      ┆ ---    ┆ ---  │
│ str      ┆ str    ┆ u32  │
╞══════════╪════════╪══════╡
│ Vincent  ┆ M      ┆ 1    │
│ John     ┆ M      ┆ 4    │
│ Tom      ┆ M      ┆ 5    │
│ Mary     ┆ F      ┆ 2    │
│ Caroline ┆ F      ┆ 3    │
│ Lisa     ┆ F      ┆ 6    │
└──────────┴────────┴──────┘

join

「"join"」則會將各組結果合成一個pl.List,並重覆出現在該組。API文件特別指出,這是一個耗費記憶體的操作,需小心使用。

(
    df2.with_columns(
        pl.col("rank")
        .sort()
        .over(pl.col("gender"), mapping_strategy="join")
    )
)
shape: (6, 3)
┌──────────┬────────┬───────────┐
│ name     ┆ gender ┆ rank      │
│ ---      ┆ ---    ┆ ---       │
│ str      ┆ str    ┆ list[u32] │
╞══════════╪════════╪═══════════╡
│ Tom      ┆ M      ┆ [1, 4, 5] │
│ Lisa     ┆ F      ┆ [2, 3, 6] │
│ John     ┆ M      ┆ [1, 4, 5] │
│ Vincent  ┆ M      ┆ [1, 4, 5] │
│ Mary     ┆ F      ┆ [2, 3, 6] │
│ Caroline ┆ F      ┆ [2, 3, 6] │
└──────────┴────────┴───────────┘

提醒

最後特別提醒,雖然pl.Expr.over()大部份情況會產生跟原先dataframe一樣的行數,但這不是一定的。舉例來說,我們可以透過pl.Expr.head()只取得各「"gender"」組內最小「"rank"」的行。也就是說在「"M"」群組中,取到該組「"rank"」最小的「"Vincent"」,而在「"F"」群組中,取到該組「"rank"」最小的「"Mary"」。

(
    df2.select(
        pl.all()
        .sort_by(pl.col("rank"))
        # for each gender, get the first row after sorting by "rank"
        .head(1)
        .over(pl.col("gender"), mapping_strategy="explode")
    )
)
shape: (2, 3)
┌─────────┬────────┬──────┐
│ name    ┆ gender ┆ rank │
│ ---     ┆ ---    ┆ ---  │
│ str     ┆ str    ┆ u32  │
╞═════════╪════════╪══════╡
│ Vincent ┆ M      ┆ 1    │
│ Mary    ┆ F      ┆ 2    │
└─────────┴────────┴──────┘

請注意,此處的pl.Expr.head(1)是在pl.Expr.over()之前,代表其是針對每一組而操作。

如果是將pl.Expr.head(1)置於pl.Expr.over()後,代表進行完pl.Expr.over()操作後,取得第一行:

(
    df2.select(
        pl.all()
        .sort_by(pl.col("rank"))
        .over(pl.col("gender"), mapping_strategy="explode")
        .head(1)
    )
)
shape: (1, 3)
┌─────────┬────────┬──────┐
│ name    ┆ gender ┆ rank │
│ ---     ┆ ---    ┆ ---  │
│ str     ┆ str    ┆ u32  │
╞═════════╪════════╪══════╡
│ Vincent ┆ M      ┆ 1    │
└─────────┴────────┴──────┘

6. codepanda

Pandas中相對應於polars的pl.DataFrame.group_by()的功能是pd.DataFrame.groupby()

例如想要針對「"has_pet"」列進行分組,並計算各組所包含的人數及找出各組中「"lucky_number"」列中最大的數值,可以這麼寫:

df_pd = pd.DataFrame(data)

(
    df_pd.groupby("has_pet").agg(
        len=("has_pet", "size"),
        lucky_number=("lucky_number", "max"),
    )
)
         len  lucky_number
has_pet                   
N          2            91
Y          4            36

如果想要以「"name"」列中各個名字的長度來做為分組依據,並計算各組所包含的人數以及收集各組所包含的名字,可以這麼寫:

(
    df_pd.assign(n_chars=df_pd["name"].str.len())
    .groupby("n_chars")
    .agg(len=("name", "size"), name=("name", list))
)
         len                name
n_chars                         
3          1               [Tom]
4          3  [Lisa, John, Mary]
7          1           [Vincent]
8          1          [Caroline]

Pandas中最接近polarspl.Expr.over()的功能是pd.DataFrame.groupby.DataFrameGroupBy.transform()

舉例來說,假如我們想針對「"gender"」列進行分組運算,計算每組內「"lucky_number"」列的rank(即排序各組內的「"lucky_number"」列,最小值為1,次小值為2,以此類推),可以這麼寫:

(
    df_pd.assign(
        rank_by_gender=lambda df_: df_.groupby("gender")
        .lucky_number.transform(lambda s_: s_.rank())
        .astype(int)
    )
)
       name has_pet gender  lucky_number  rank_by_gender
0       Tom       Y      M            19               2
1      Lisa       N      F            25               2
2      John       Y      M            36               3
3   Vincent       Y      M             7               1
4      Mary       Y      F             2               1
5  Caroline       N      F            91               3

備註

註1:多次執行相同的分組聚合運算時,其各行順序不一定會一樣,也就是可能會出現「"N"」出現在「"Y"」前面的情況,例如:

shape: (2, 3)
┌─────────┬─────┬──────────────┐
│ has_pet ┆ len ┆ lucky_number │
│ ---     ┆ --- ┆ ---          │
│ str     ┆ u32 ┆ i64          │
╞═════════╪═════╪══════════════╡
│ N       ┆ 2   ┆ 91           │
│ Y       ┆ 4   ┆ 36           │
└─────────┴─────┴──────────────┘

想要維持各行為固定順序的話,有兩個方法:

註2:這裡使用了pl.Expr.not_()來做反向(negate)布林運算。如果不習慣這樣寫法的朋友,可以自行拼湊出反向的expr,如:

lt20 = pl.col("lucky_number").lt(20)
ge20 = pl.col("lucky_number").ge(20)


(
    df.group_by("gender").agg(
        lt20.sum().alias("lt20"), ge20.sum().alias("ge20")
    )
)
shape: (2, 3)
┌────────┬──────┬──────┐
│ gender ┆ lt20 ┆ ge20 │
│ ---    ┆ ---  ┆ ---  │
│ str    ┆ u32  ┆ u32  │
╞════════╪══════╪══════╡
│ M      ┆ 2    ┆ 1    │
│ F      ┆ 1    ┆ 2    │
└────────┴──────┴──────┘

註3:pl.Expr.over()的第一個參數method=,是為了判斷當兩個元素相等時,如何決定大小所用,其預設值為「"average"」,型別會是Pl.Float64。由於我們的例題中不會出現這樣的情況,所以我選擇指定method=ordinal,型別為pl.UInt32

Code

本日程式碼傳送門


上一篇
[Day11] - Context:pl.DataFrame.filter()
下一篇
[Day13] - Datatype:Temporal
系列文
Polars熊霸天下14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言